PDX-474/475: feat(mcp): add depth guard and token attribution middleware#169
Merged
Conversation
Quality Orchestrator🟢 LOW · 🧪 Tests to Run · Running 2 of 48 tests
▶ Run commandnpx vitest run \
unit/mcp/server.test.ts \
unit/mcp/tokenMeta.test.ts⚡ quality-orchestrator · |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds two MCP server middlewares to improve agent-loop safety and observability: a per-session tool-call depth guard (PROVAR_MCP_MAX_TOOL_DEPTH) and optional per-call token attribution metadata (PROVAR_MCP_EMIT_TOKEN_META). This is implemented by monkey-patching McpServer.registerTool after provardx_ping is registered so ping remains exempt.
Changes:
- Introduces
src/mcp/utils/tokenMeta.tswith depth-guard state, tool handler wrapping, and_metaattachment utilities. - Wraps MCP tool registration in
src/mcp/server.tsto enforce per-session call limits (default 50) and emit token metadata when enabled. - Adds documentation for both env vars and a new unit/integration test suite covering depth guard + meta behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/unit/mcp/tokenMeta.test.ts | Adds unit + integration tests for depth guard and token meta attachment. |
| src/mcp/utils/tokenMeta.ts | Implements depth-guard tracking, budget-exceeded error shaping, token estimation, and _meta attachment. |
| src/mcp/server.ts | Patches registerTool to wrap all tools (except provardx_ping) with the depth guard middleware. |
| docs/mcp.md | Documents performance tuning env vars and expected response/meta shapes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // ── Depth-guard middleware (PDX-474) ───────────────────────────────────────── | ||
| const rawLimit = parseInt(process.env['PROVAR_MCP_MAX_TOOL_DEPTH'] ?? '50', 10); | ||
| const depthLimit = Number.isNaN(rawLimit) ? 50 : rawLimit; |
Comment on lines
+88
to
+94
| entry.calls++; | ||
| const result = await handler(args, extra); | ||
| const tokens = estimateTokens(result); | ||
| entry.totalEstimatedTokens += tokens; | ||
|
|
||
| const detailLevel = typeof args['detail'] === 'string' ? args['detail'] : 'standard'; | ||
| return attachMeta(result, toolName, detailLevel); |
Req: Agent tools must be preventable from running unchecked agentic loops that exhaust context without returning to the user. Observability tooling also needs per-call token cost signals to track LLM usage across sessions. Fix: PROVAR_MCP_MAX_TOOL_DEPTH caps tool calls per MCP session (default 50) with TOOL_BUDGET_EXCEEDED errors; PROVAR_MCP_EMIT_TOKEN_META appends a _meta token-attribution block to structuredContent (PDX-475). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on when meta disabled Req: Negative values for PROVAR_MCP_MAX_TOOL_DEPTH produced a nonsensical negative limit in TOOL_BUDGET_EXCEEDED responses. Token estimation via JSON.stringify ran on every tool call even when PROVAR_MCP_EMIT_TOKEN_META was disabled, adding avoidable overhead. Fix: Treat negative parsed values the same as NaN (fall back to 50). Guard token estimation and accumulation behind the PROVAR_MCP_EMIT_TOKEN_META=true check so it is skipped entirely when meta is disabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Req: Depth limit of 0 caused every tool call to fail immediately with TOOL_BUDGET_EXCEEDED because the guard condition is entry.calls >= limit. Fix: Change the negative-value guard from rawLimit < 0 to rawLimit <= 0 so zero is treated as invalid and falls back to the default of 50. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5cd1014 to
50c8cf8
Compare
…tdio transports
RCA: wrapWithDepthGuard fell back to `anon-${randomUUID()}` when extra.sessionId was absent, generating a fresh sessionId for every call. Each call created a new SessionEntry with calls=0, so entry.calls >= limit never tripped on stdio clients (Claude Desktop, Cursor, etc.) that don't supply a sessionId.
Fix: Fall back to the literal string 'anon' so all sessionless callers share one bucket and the budget actually limits runaway tool use. Remove the now-unused crypto.randomUUID import. Flip the prior test ("assigns a unique anon session UUID per call when sessionId is absent") to assert the corrected behavior — second anon call returns TOOL_BUDGET_EXCEEDED — and add a sanity test that named sessions remain independent from the anon bucket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PROVAR_MCP_MAX_TOOL_DEPTHenv var: per-session agentic loop depth guard. Caps tool calls at N (default 50) per MCP session; returnsTOOL_BUDGET_EXCEEDEDwithcallsMade/limit/suggestionfields once the limit is hit.provardx_pingis exempt.PROVAR_MCP_EMIT_TOKEN_METAenv var: per-call token attribution. Whentrue, appends a_metablock tostructuredContentwithtool,detailLevel, andestimatedTokens; budget-exceeded errors also includesessionTotalEstimatedTokens.src/mcp/utils/tokenMeta.tswithwrapWithDepthGuard,attachMeta,estimateTokens, andcreateDepthGuardState.patchWithMiddlewareinserver.tsmonkey-patchesregisterToolafterprovardx_pingregistration so all Provar tools are wrapped but ping is exempt.docs/mcp.md— new Performance Tuning section with env var reference.Implementation notes
patchWithMiddlewarecasts throughunknown(notany) to avoid@typescript-eslint/no-unsafe-member-access; the runtime overload shape is safe becauseMcpServer.registerToolalways accepts(name, config, handler).sessionIdfrom the MCP SDK get a uniqueanon-${randomUUID()}per call, so anonymous calls never share state.Test plan
test/unit/mcp/tokenMeta.test.ts— all passing--noEmit— cleanprovardx_pingis exempt: depth guard patch runs AFTER ping registrationPROVAR_MCP_MAX_TOOL_DEPTH=0— every tool call returns TOOL_BUDGET_EXCEEDED immediatelyPROVAR_MCP_EMIT_TOKEN_META=true—structuredContent._metapresent;content[0].textunchangedMerge order
This PR touches
src/mcp/server.ts. Merge after Thread A (PDX-468/469/471/473) if that PR also modifies server.ts, to resolve conflicts cleanly.🤖 Generated with Claude Code